@coreplane/switchboard 1.262.4 → 1.263.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,113 @@
1
+ import { MAX_PR_IMAGE_BYTES, prImageKey, readPrImage, validPrImageSource } from "../../src/artifacts/prImages.ts";
2
+ import { DAY_MS } from "../../src/core/budgets.ts";
3
+ import { systemClock } from "../../src/core/trace/clock.ts";
4
+ import { sameToken } from "./artifactsCopy.ts";
5
+
6
+ /** The small part of R2 needed for deliberate publication. No list or public
7
+ * bucket capability: reads can address only copies this handler marked. */
8
+ export interface PrImageBucket {
9
+ get(key: string): Promise<{
10
+ size: number;
11
+ body: ReadableStream<Uint8Array>;
12
+ httpMetadata?: { contentType?: string };
13
+ customMetadata?: Record<string, string>;
14
+ } | null>;
15
+ put(
16
+ key: string,
17
+ bytes: Uint8Array,
18
+ options: {
19
+ httpMetadata: { contentType: string };
20
+ customMetadata: Record<string, string>;
21
+ },
22
+ ): Promise<unknown>;
23
+ }
24
+
25
+ const json = (status: number, body: Record<string, unknown>) =>
26
+ new Response(JSON.stringify(body), {
27
+ status,
28
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
29
+ });
30
+
31
+ export async function publishPrImage(
32
+ request: Request,
33
+ deps: { bucket?: PrImageBucket; token?: string; clock?: () => number },
34
+ ): Promise<Response> {
35
+ const bearer = /^Bearer\s+(\S+)$/.exec(request.headers.get("authorization") ?? "")?.[1];
36
+ if (!deps.token || !bearer || !sameToken(bearer, deps.token))
37
+ return json(401, { error: "publication bearer refused" });
38
+ if (request.method !== "POST") return json(405, { error: "publication requires POST" });
39
+ if (!deps.bucket) return json(503, { error: "artifact store unavailable" });
40
+ let key: unknown;
41
+ let retentionDays: unknown;
42
+ try {
43
+ const input = (await request.json()) as { key?: unknown; retentionDays?: unknown } | null;
44
+ key = input?.key;
45
+ retentionDays = input?.retentionDays;
46
+ } catch {
47
+ /* Refuse below, before reading storage. */
48
+ }
49
+ if (typeof key !== "string" || !validPrImageSource(key))
50
+ return json(400, { error: "publication requires an outbound artifact key" });
51
+ if (typeof retentionDays !== "number" || !Number.isSafeInteger(retentionDays) || retentionDays < 1)
52
+ return json(400, { error: "publication requires retentionDays as a positive integer" });
53
+ const expiresAt = (deps.clock ?? systemClock)() + retentionDays * DAY_MS;
54
+ if (!Number.isSafeInteger(expiresAt) || expiresAt < 1)
55
+ return json(400, { error: "publication expiry is out of range" });
56
+ const object = await deps.bucket.get(key);
57
+ if (!object) return json(404, { error: "artifact not found" });
58
+ let bytes: Uint8Array;
59
+ try {
60
+ bytes = await readPrImage({ ...object, contentType: object.httpMetadata?.contentType ?? "" });
61
+ } catch {
62
+ // A storage stream can fail with internal paths or stack details; the caller
63
+ // needs only the stable validation result.
64
+ return json(400, { error: "invalid PR image" });
65
+ }
66
+ const path = `/pr-images/${crypto.randomUUID()}.png`;
67
+ await deps.bucket.put(prImageKey(path)!, bytes, {
68
+ httpMetadata: { contentType: "image/png" },
69
+ customMetadata: { publishedForPr: "true", sourceKey: key, expiresAt: String(expiresAt) },
70
+ });
71
+ return json(200, { path });
72
+ }
73
+
74
+ /** Public only by deliberate copy. The URL is an address, not a run-reader
75
+ * capability; there is no way to substitute a private key into this route. */
76
+ export async function handlePrImage(
77
+ request: Request,
78
+ bucket?: PrImageBucket,
79
+ clock: () => number = systemClock,
80
+ ): Promise<Response> {
81
+ if (request.method !== "GET" && request.method !== "HEAD") return json(405, { error: "GET or HEAD only" });
82
+ const url = new URL(request.url);
83
+ const key = prImageKey(url.pathname);
84
+ if (!key || url.search || !bucket) return json(404, { error: "not found" });
85
+ const object = await bucket.get(key);
86
+ // Lifecycle deletion is asynchronous; it must not extend public availability.
87
+ // Older or malformed copies without a valid deadline also fail closed.
88
+ const expiresAt = Number(object?.customMetadata?.expiresAt);
89
+ if (
90
+ !object ||
91
+ object.customMetadata?.publishedForPr !== "true" ||
92
+ !Number.isSafeInteger(expiresAt) ||
93
+ expiresAt < 1 ||
94
+ clock() >= expiresAt ||
95
+ object.httpMetadata?.contentType !== "image/png" ||
96
+ object.size < 1 ||
97
+ object.size > MAX_PR_IMAGE_BYTES
98
+ ) {
99
+ await object?.body.cancel();
100
+ return json(404, { error: "not found" });
101
+ }
102
+ if (request.method === "HEAD") await object.body.cancel();
103
+ return new Response(request.method === "HEAD" ? null : object.body, {
104
+ headers: {
105
+ "content-type": "image/png",
106
+ "content-length": String(object.size),
107
+ "content-disposition": 'inline; filename="screenshot.png"',
108
+ "cache-control": "no-store",
109
+ "x-content-type-options": "nosniff",
110
+ "content-security-policy": "default-src 'none'; sandbox",
111
+ },
112
+ });
113
+ }
@@ -46,6 +46,8 @@ import { systemClock } from "../../src/core/trace/clock.ts";
46
46
  import { createTracer } from "../../src/core/trace/tracer.ts";
47
47
  import { shimRoute, stripTraceContext, withTraceContext, workerLogSink } from "../../src/core/trace/workerTrace.ts";
48
48
  import { COPY_PATH, handleArtifactsCopy } from "./artifactsCopy.ts";
49
+ import { handlePrImage, publishPrImage } from "./prImages.ts";
50
+ import { PR_IMAGE_PUBLISH_PATH } from "../../src/artifacts/prImages.ts";
49
51
  import { withKnownLength } from "./knownLength.ts";
50
52
  import type { ShipCoordinatorParams } from "./coordinator";
51
53
  import { INSTANCE, INTERNAL } from "./shared";
@@ -456,6 +458,11 @@ const withLength = (res: Response): Response => withKnownLength(res, (size) => n
456
458
  export default {
457
459
  async fetch(request: Request, env: Env): Promise<Response> {
458
460
  const pathname = new URL(request.url).pathname;
461
+ // Only deliberately published copies are public. The existing run reader
462
+ // and the bucket stay private; the write endpoint checks its own bearer.
463
+ if (pathname.startsWith("/pr-images/")) return withLength(await handlePrImage(request, env.ARTIFACTS));
464
+ if (pathname === PR_IMAGE_PUBLISH_PATH)
465
+ return publishPrImage(request, { bucket: env.ARTIFACTS, token: env.ARTIFACTS_COPY_TOKEN });
459
466
  // The public edge (docs/reference/specs/tracing.md item 22): whatever trace context the
460
467
  // caller sent is stripped, and what the container sees carries this
461
468
  // Worker's own root. A static asset or the live view's SSE stream gets no
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.262.4",
3
+ "version": "1.263.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.262.4",
9
+ "version": "1.263.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -21060,7 +21060,7 @@
21060
21060
  },
21061
21061
  "packages/switchboard": {
21062
21062
  "name": "@coreplane/switchboard",
21063
- "version": "1.262.4",
21063
+ "version": "1.263.0",
21064
21064
  "license": "Apache-2.0",
21065
21065
  "dependencies": {
21066
21066
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.262.4",
3
+ "version": "1.263.0",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.262.4",
3
- "commit": "7e76aedb8ad5b5ed4a0a93daa300937a9b7272e8",
4
- "builtAt": "2026-09-25T21:40:28.832Z"
2
+ "version": "1.263.0",
3
+ "commit": "45ff7fda11fb77898cd9677f0c6e42efd4f9ffbf",
4
+ "builtAt": "2026-09-26T01:33:01.056Z"
5
5
  }
@@ -216,7 +216,7 @@ const RESIDENT_TOOLCHAIN = `The resident image carries ${IMAGE_TOOLCHAIN}; plus
216
216
  // item 10): the one way a run's screenshot reaches the person. Said once so
217
217
  // the sandbox and resident variants cannot drift on it.
218
218
  const SHOW_FILES = `Files the person should SEE go through the attach_file tool: a screenshot from \`playwright screenshot\`, a rendered PDF, a recording — it posts the workspace file into this conversation, where an image renders inline. Use it whenever you produce an image worth showing (a visual change, a rendered page, a before/after); a link to a file on GitHub is not a picture.
219
- SCREENSHOTS GO TO BOTH PLACES, ALL OF THEM: when the request asks for screenshots, or the change is visual, every capture is attached here with attach_file AND published on the pull request — commit the images to an assets branch (never the PR's own diff) and reference them from the description's validation section or a PR comment so they render inline there too — unless the request names one destination. Never attach a subset and link the rest.
219
+ SCREENSHOTS GO TO BOTH PLACES, ALL OF THEM: when the request asks for screenshots, or the change is visual, every capture is attached here with attach_file AND published on the pull request — use PNG captures, inspect/redact sensitive content, and call attach_file with publishForPr: true to deliberately make a public copy after thread delivery. Embed the returned public URL with Markdown image syntax in the description's validation section or a PR comment, never as a bare link. This applies unless the request names one destination; for thread-only delivery omit publishForPr. Never attach a subset and link the rest. Only deliberately published PNGs (at most 10 MiB) are public, for the artifact retention window (default 30 days), not forever. Never push an assets branch, put captures in the PR's diff, or expose a tokened run-artifact URL as a workaround. If publication is unavailable or refused, report that limit; if attachment was refused too, still attach privately with publishForPr omitted. Do not claim the PR renders an image.
220
220
  Whole files: up to 1 GiB where the artifact store is configured (a recording, a large PDF), 10 MiB otherwise — the tool's result says which applies; over the limit, link to the file.
221
221
  Files the person dropped on the thread that were too large to show you inline are already in ./attachments/ in your workspace when the turn's text names them (a video for ffmpeg, a large PDF, a zip); read them from there — never ask for a re-upload.
222
222
  Text stays in your message; do not attach what you can say.`;
@@ -0,0 +1,51 @@
1
+ // Deliberate public copies, not a public bucket or an exception to the run reader.
2
+ export const PR_IMAGE_PUBLISH_PATH = "/artifacts/publish-pr-image";
3
+ export const MAX_PR_IMAGE_BYTES = 10 * 1024 * 1024;
4
+ const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";
5
+ const PUBLIC_PATH = new RegExp(`^/pr-images/(${UUID})\\.png$`);
6
+
7
+ export function validPrImagePath(path: string): boolean {
8
+ return PUBLIC_PATH.test(path);
9
+ }
10
+
11
+ export function prImageKey(path: string): string | undefined {
12
+ const match = PUBLIC_PATH.exec(path);
13
+ return match ? `published-pr/${match[1]}.png` : undefined;
14
+ }
15
+
16
+ export function validPrImageSource(key: string): boolean {
17
+ return /^runs\/[A-Za-z0-9_-]+\/out\/\d+-[A-Za-z0-9._-]+$/.test(key);
18
+ }
19
+
20
+ /** Bounded to one small screenshot in the Worker, never in the bot process.
21
+ * Count the stream as well as its metadata: a dishonest length must not
22
+ * turn the public endpoint into an unbounded read or an active-content host. */
23
+ export async function readPrImage(object: {
24
+ size: number;
25
+ contentType: string;
26
+ body: ReadableStream<Uint8Array>;
27
+ }): Promise<Uint8Array> {
28
+ const reader = object.body.getReader();
29
+ try {
30
+ if (object.contentType !== "image/png") throw new Error("PR images require PNG content type");
31
+ if (!Number.isInteger(object.size) || object.size < 1 || object.size > MAX_PR_IMAGE_BYTES) {
32
+ throw new Error(`PR image size must be 1..${MAX_PR_IMAGE_BYTES} bytes`);
33
+ }
34
+ const bytes = new Uint8Array(object.size);
35
+ let used = 0;
36
+ for (;;) {
37
+ const { done, value } = await reader.read();
38
+ if (done) break;
39
+ if (used + value.length > bytes.length) throw new Error("PR image length exceeds its declared size");
40
+ bytes.set(value, used);
41
+ used += value.length;
42
+ }
43
+ if (used !== bytes.length) throw new Error("PR image length differs from its declared size");
44
+ const signature = [137, 80, 78, 71, 13, 10, 26, 10];
45
+ if (!signature.every((byte, index) => bytes[index] === byte)) throw new Error("PR images require a PNG signature");
46
+ return bytes;
47
+ } finally {
48
+ await reader.cancel().catch(() => {});
49
+ reader.releaseLock();
50
+ }
51
+ }
@@ -446,7 +446,7 @@ export function classifyProviderFailure(input: ProviderFailureAnswer): ProviderF
446
446
  });
447
447
  }
448
448
 
449
- function typedProviderFailureOf(error: unknown, seen = new Set<object>()): ProviderFailure | undefined {
449
+ export function typedProviderFailureOf(error: unknown, seen = new Set<object>()): ProviderFailure | undefined {
450
450
  if (error instanceof ProviderFailure) return error;
451
451
  const row = providerFailureRecord(error);
452
452
  if (!row || seen.has(row)) return undefined;
package/dist/cli.js CHANGED
@@ -362,6 +362,24 @@ var init_clock = __esm({
362
362
  }
363
363
  });
364
364
 
365
+ // ../../src/artifacts/prImages.ts
366
+ function validPrImagePath(path) {
367
+ return PUBLIC_PATH.test(path);
368
+ }
369
+ function validPrImageSource(key) {
370
+ return /^runs\/[A-Za-z0-9_-]+\/out\/\d+-[A-Za-z0-9._-]+$/.test(key);
371
+ }
372
+ var PR_IMAGE_PUBLISH_PATH, MAX_PR_IMAGE_BYTES, UUID, PUBLIC_PATH;
373
+ var init_prImages = __esm({
374
+ "../../src/artifacts/prImages.ts"() {
375
+ "use strict";
376
+ PR_IMAGE_PUBLISH_PATH = "/artifacts/publish-pr-image";
377
+ MAX_PR_IMAGE_BYTES = 10 * 1024 * 1024;
378
+ UUID = "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";
379
+ PUBLIC_PATH = new RegExp(`^/pr-images/(${UUID})\\.png$`);
380
+ }
381
+ });
382
+
365
383
  // ../../src/artifacts/store.ts
366
384
  import { AwsClient } from "aws4fetch";
367
385
  function parseByteRange(header4) {
@@ -392,6 +410,7 @@ var init_store = __esm({
392
410
  "use strict";
393
411
  init_clock();
394
412
  init_config();
413
+ init_prImages();
395
414
  PRESIGN_TTL_SECONDS = ARTIFACT_DEFAULTS.presignTtlSeconds;
396
415
  IDENTITY = { "accept-encoding": "identity" };
397
416
  R2ArtifactStore = class {
@@ -400,6 +419,7 @@ var init_store = __esm({
400
419
  endpoint;
401
420
  fetchImpl;
402
421
  clock;
422
+ retentionDays;
403
423
  copy;
404
424
  constructor(opts) {
405
425
  this.bucket = opts.bucket;
@@ -413,6 +433,7 @@ var init_store = __esm({
413
433
  });
414
434
  this.fetchImpl = opts.fetch ?? fetch;
415
435
  this.clock = opts.clock ?? systemClock;
436
+ this.retentionDays = opts.retentionDays ?? ARTIFACT_DEFAULTS.retentionDays;
416
437
  this.copy = { ...opts.copy, timeoutMs: opts.copy.timeoutMs ?? ARTIFACT_DEFAULTS.copyTimeoutMs };
417
438
  }
418
439
  objectUrl(key) {
@@ -472,6 +493,26 @@ var init_store = __esm({
472
493
  if (!Number.isFinite(size) || size < 0) throw new Error(`artifact store: GET ${key} answered without a length`);
473
494
  return { size, contentType, body: res.body };
474
495
  }
496
+ async publishPrImage(key) {
497
+ if (!validPrImageSource(key)) throw new Error("PR publication requires an outbound artifact key");
498
+ const base = new URL(this.copy.baseUrl);
499
+ if (base.protocol !== "https:" || base.username || base.password || base.search || base.hash || base.pathname !== "/") {
500
+ throw new Error("PR publication requires a credential-free HTTPS origin");
501
+ }
502
+ const res = await this.fetchImpl(`${base.origin}${PR_IMAGE_PUBLISH_PATH}`, {
503
+ method: "POST",
504
+ headers: { authorization: `Bearer ${this.copy.token.reveal()}`, "content-type": "application/json" },
505
+ body: JSON.stringify({ key, retentionDays: this.retentionDays }),
506
+ redirect: "error",
507
+ signal: AbortSignal.timeout(this.copy.timeoutMs)
508
+ });
509
+ if (!res.ok) throw new Error(`PR publication answered HTTP ${res.status}`);
510
+ const answer = await res.json();
511
+ if (typeof answer.path !== "string" || !validPrImagePath(answer.path)) {
512
+ throw new Error("PR publication returned an invalid public path");
513
+ }
514
+ return `${base.origin}${answer.path}`;
515
+ }
475
516
  async copyFromUrl(input) {
476
517
  const res = await this.fetchImpl(`${this.copy.baseUrl.replace(/\/$/, "")}/artifacts/copy`, {
477
518
  method: "POST",
@@ -509,6 +550,7 @@ function buildArtifactStore(cfg, secrets, opts) {
509
550
  return new R2ArtifactStore({
510
551
  accountId: cfg.r2.accountId,
511
552
  bucket: cfg.r2.bucket,
553
+ retentionDays: cfg.retentionDays,
512
554
  accessKeyId: secrets.get("ARTIFACTS_R2_ACCESS_KEY_ID"),
513
555
  secretAccessKey: secrets.get("ARTIFACTS_R2_SECRET_ACCESS_KEY"),
514
556
  copy: {
@@ -2335,7 +2377,7 @@ EVERY PR includes one that already exists when you push \u2014 opened by a perso
2335
2377
  SANDBOX_TOOLCHAIN = `The sandbox image carries ${IMAGE_TOOLCHAIN}; and Docker (the engine starts on the first \`docker\` call).`;
2336
2378
  RESIDENT_TOOLCHAIN = `The resident image carries ${IMAGE_TOOLCHAIN}; plus yarn and bun \u2014 and no Docker.`;
2337
2379
  SHOW_FILES = `Files the person should SEE go through the attach_file tool: a screenshot from \`playwright screenshot\`, a rendered PDF, a recording \u2014 it posts the workspace file into this conversation, where an image renders inline. Use it whenever you produce an image worth showing (a visual change, a rendered page, a before/after); a link to a file on GitHub is not a picture.
2338
- SCREENSHOTS GO TO BOTH PLACES, ALL OF THEM: when the request asks for screenshots, or the change is visual, every capture is attached here with attach_file AND published on the pull request \u2014 commit the images to an assets branch (never the PR's own diff) and reference them from the description's validation section or a PR comment so they render inline there too \u2014 unless the request names one destination. Never attach a subset and link the rest.
2380
+ SCREENSHOTS GO TO BOTH PLACES, ALL OF THEM: when the request asks for screenshots, or the change is visual, every capture is attached here with attach_file AND published on the pull request \u2014 use PNG captures, inspect/redact sensitive content, and call attach_file with publishForPr: true to deliberately make a public copy after thread delivery. Embed the returned public URL with Markdown image syntax in the description's validation section or a PR comment, never as a bare link. This applies unless the request names one destination; for thread-only delivery omit publishForPr. Never attach a subset and link the rest. Only deliberately published PNGs (at most 10 MiB) are public, for the artifact retention window (default 30 days), not forever. Never push an assets branch, put captures in the PR's diff, or expose a tokened run-artifact URL as a workaround. If publication is unavailable or refused, report that limit; if attachment was refused too, still attach privately with publishForPr omitted. Do not claim the PR renders an image.
2339
2381
  Whole files: up to 1 GiB where the artifact store is configured (a recording, a large PDF), 10 MiB otherwise \u2014 the tool's result says which applies; over the limit, link to the file.
2340
2382
  Files the person dropped on the thread that were too large to show you inline are already in ./attachments/ in your workspace when the turn's text names them (a video for ffmpeg, a large PDF, a zip); read them from there \u2014 never ask for a re-upload.
2341
2383
  Text stays in your message; do not attach what you can say.`;
@@ -40962,6 +41004,39 @@ function operatorPresets() {
40962
41004
  }
40963
41005
  ];
40964
41006
  }
41007
+ function requestedModelWord(request2, modelWord, ref) {
41008
+ const word = modelWord.trim().toLowerCase();
41009
+ if (!word) return false;
41010
+ const words = [];
41011
+ let current = "";
41012
+ for (const char of request2.toLowerCase()) {
41013
+ const code = char.charCodeAt(0);
41014
+ if (code >= 97 && code <= 122 || code >= 48 && code <= 57 || char === "-" || char === ".") {
41015
+ current += char;
41016
+ } else if (current) {
41017
+ words.push(current);
41018
+ current = "";
41019
+ }
41020
+ }
41021
+ if (current) words.push(current);
41022
+ const cues = /* @__PURE__ */ new Set(["with", "using", "use", "on", "via", "model"]);
41023
+ let selected = false;
41024
+ for (let i = 0; i < words.length; i++) {
41025
+ if (words[i] !== word) continue;
41026
+ const previous = words[i - 1];
41027
+ if (previous !== void 0 && cues.has(previous)) selected = true;
41028
+ if ((previous === "the" || previous === "a") && i >= 2 && cues.has(words[i - 2])) selected = true;
41029
+ }
41030
+ if (!selected) return false;
41031
+ let modelId;
41032
+ try {
41033
+ modelId = parseModelRef(ref).model.toLowerCase();
41034
+ } catch {
41035
+ return true;
41036
+ }
41037
+ const modelName = modelId.split("/").pop();
41038
+ return modelName === word || modelName?.endsWith(`-${word}`) === true;
41039
+ }
40965
41040
  function compareCapability(a, b) {
40966
41041
  const machine = Number(a.machine !== "none") - Number(b.machine !== "none");
40967
41042
  if (machine !== 0) return machine;
@@ -41021,8 +41096,8 @@ function buildOperatorPrompt(input) {
41021
41096
  ] : [],
41022
41097
  "A write ask in a named or inherited repository binds the write preset even when a detail inside it is unresolved \u2014 the run it starts resolves the detail with the repository in front of it. Ask a question only for a fork the run itself could not resolve, and a question's proposal must be a line that would do the asked work: a write line for a write ask, never a read (an exploration, a listing, a summary) standing in for the work.",
41023
41098
  "A read command answers only a read intent: an ask to change, set, switch or update something is a write, and a listing or a show never answers it. Every command call declares its `intent`. When a write ask misses a required detail, or names a model provider this deployment does not have, read `provider_models` for the refs this deployment can run, then call `ask` with a proposal that would do the write built from them \u2014 the person's yes runs it, and their next words refine it.",
41024
- "When the request names a model in plain words \u2014 'with astra, \u2026', 'use sol for this', 'on gpt-6' \u2014 read `provider_models` to resolve the word to exactly ONE ref this deployment can run and pass that ref as `bind_preset`'s `model`: the run then uses it, exactly as a typed `model:` directive would. The request still rides verbatim \u2014 never strip the model word from it. A word that matches several refs, or none, is one `ask` naming the catalogue's candidate refs \u2014 never a guess and never a silent default; a request naming no model passes no `model`.",
41025
- "`bind_preset` runs the preset on the request as the author asked it \u2014 the author's own words ride by reference, so the call names only the preset, the optional model, and the reason: never re-type the request, never a flag form and never a paraphrase.",
41099
+ "When the request names a model in plain words \u2014 'with astra, \u2026', 'use sol for this', 'on gpt-6' \u2014 read `provider_models` to resolve the word to exactly ONE ref this deployment can run. Pass that ref as `bind_preset`'s `model` and one exact model-name word from the person's request as `modelWord` (such as 'astra', 'o3', or 'gpt-6'): the run then uses it, exactly as a typed `model:` directive would. The request still rides verbatim \u2014 never strip the model word from it. A word that matches several refs, or none, is one `ask` naming the catalogue's candidate refs \u2014 never a guess and never a silent default; a request naming no model omits both `model` and `modelWord`.",
41100
+ "`bind_preset` runs the preset on the request as the author asked it \u2014 the author's own words ride by reference, so the call names only the preset, optional model and modelWord, and the reason: never re-type the request, never a flag form and never a paraphrase.",
41026
41101
  "",
41027
41102
  // 2. Projection: the presets and commands THIS author may run.
41028
41103
  "Presets this author may run:",
@@ -41084,6 +41159,10 @@ function operatorTools(input) {
41084
41159
  type: "string",
41085
41160
  description: "the model ref the run uses, ONLY when the request names a model in plain words: a `<provider>/<model>` ref `provider_models` lists, resolved from the person's word \u2014 omit when no model is named, and ask instead of guessing when the word matches several refs or none"
41086
41161
  },
41162
+ modelWord: {
41163
+ type: "string",
41164
+ description: "one exact model-name word in the person's request that `model` resolves, such as astra, o3, or gpt-6; omit with `model` when no model is named"
41165
+ },
41087
41166
  repo: {
41088
41167
  type: "string",
41089
41168
  pattern: "^[\\w.-]+/[\\w.-]+$",
@@ -41285,15 +41364,15 @@ function parseOperatorTurn(answer, ctx) {
41285
41364
  ...typeof input.filter === "string" && input.filter.trim().length > 0 ? { filter: input.filter } : {}
41286
41365
  };
41287
41366
  if (answer.tool === OPERATOR_BIND_TOOL) {
41288
- const { preset, reason: reason2, model, repo } = input;
41367
+ const { preset, reason: reason2, model, modelWord, repo } = input;
41289
41368
  if (typeof preset !== "string" || !ctx.presets.includes(preset))
41290
41369
  return {
41291
41370
  kind: "violation",
41292
41371
  violation: `bind_preset named "${String(preset)}", not a preset the projection offers`
41293
41372
  };
41294
41373
  let ref;
41295
- if (model !== void 0) {
41296
- const trimmed2 = typeof model === "string" ? model.trim() : "";
41374
+ if (typeof model === "string" && model.trim() !== "" && typeof modelWord === "string" && requestedModelWord(ctx.requestText, modelWord, model)) {
41375
+ const trimmed2 = model.trim();
41297
41376
  const parsed = /^([A-Za-z0-9_.-]+)\/\S+$/.exec(trimmed2);
41298
41377
  if (parsed === null)
41299
41378
  return {
@@ -41504,6 +41583,16 @@ async function runOperator(input, model, opts = {}) {
41504
41583
  const carried = attemptsOfThrow(err2);
41505
41584
  if (carried) attempts.push(...carried);
41506
41585
  const failure2 = providerFailureOf(err2);
41586
+ const typedFailure = typedProviderFailureOf(err2);
41587
+ const localAbort = err2 instanceof Error && err2.name === "AbortError";
41588
+ if (signal2.aborted && typedFailure === void 0 && (localAbort || failure2.cause === "permanent" && failure2.status === void 0)) {
41589
+ return answered({
41590
+ kind: "refusal",
41591
+ cause: "timeout",
41592
+ reason: "operator_timeout",
41593
+ text: "The routing request timed out; nothing started."
41594
+ });
41595
+ }
41507
41596
  const rejected = failure2.cause === "request-rejected" && failure2.status === 400 ? failure2.schemaRejection : void 0;
41508
41597
  const offered = rejected !== void 0 && [prompt.tool, ...prompt.tools ?? []].some((candidate) => candidate.name === rejected.tool);
41509
41598
  const retry = offered && rejected !== void 0 && schemaReasks < OPERATOR_SCHEMA_REASKS_MAX ? promptWithoutTool(prompt, rejected.tool) : void 0;
@@ -41744,9 +41833,11 @@ async function executeOperatorDecision(deps, ctx) {
41744
41833
  await recordOperatorDecision(deps, msg, event, ctx.ending, ctx.trace);
41745
41834
  return answered;
41746
41835
  }
41747
- if (event.providerFailure !== void 0) {
41836
+ if (event.providerFailure !== void 0 || event.refusalCause === "timeout") {
41748
41837
  io.requestFailed?.();
41749
- await io.reply(event.refusalText ?? renderProviderFailure(event.providerFailure, "ended"));
41838
+ await io.reply(
41839
+ event.refusalText ?? (event.providerFailure !== void 0 ? renderProviderFailure(event.providerFailure, "ended") : "The routing request timed out; nothing started.")
41840
+ );
41750
41841
  await recordOperatorDecision(deps, msg, event, ctx.ending, ctx.trace);
41751
41842
  return answered;
41752
41843
  }
@@ -41890,7 +41981,7 @@ var init_operator = __esm({
41890
41981
  };
41891
41982
  OPERATOR_READS_MAX = 4;
41892
41983
  OPERATOR_SCHEMA_REASKS_MAX = 4;
41893
- OPERATOR_TIMEOUT_MS = 2e4;
41984
+ OPERATOR_TIMEOUT_MS = MINUTE_MS;
41894
41985
  OPERATOR_QUESTION_MARKER = "Did you mean:";
41895
41986
  OPERATOR_REQUEST_CAP = 600;
41896
41987
  OPERATOR_SOURCES_MAX = 12;
@@ -43149,7 +43240,7 @@ function parsePutReport(output) {
43149
43240
  function postCommandFor(path, url) {
43150
43241
  return `curl -fsS --upload-file ${shellQuote(path)} -X POST ${shellQuote(url)}`;
43151
43242
  }
43152
- async function attachThroughStore(path, name, lead, artifacts, ctx) {
43243
+ async function attachThroughStore(path, name, lead, artifacts, ctx, publishForPr) {
43153
43244
  const exec = (command2, timeoutMs2) => {
43154
43245
  const opts = { timeoutMs: timeoutMs2, ...ctx.signal ? { signal: ctx.signal } : {} };
43155
43246
  return ctx.executor.exec(command2, opts);
@@ -43161,6 +43252,7 @@ async function attachThroughStore(path, name, lead, artifacts, ctx) {
43161
43252
  const size = parseByteSize(statOut);
43162
43253
  if (size === null) return `error: could not measure ${path}: ${statOut.trim()}`;
43163
43254
  if (size === 0) return `error: ${path} is empty \u2014 nothing to attach`;
43255
+ if (publishForPr && size > MAX_PR_IMAGE_BYTES) return `error: PR images take at most ${MAX_PR_IMAGE_BYTES} bytes`;
43164
43256
  if (size > MAX_ARTIFACT_BYTES) {
43165
43257
  return `error: ${path} is ${size} bytes; attach_file takes files up to ${MAX_ARTIFACT_BYTES} bytes (1 GiB) \u2014 link to the file instead`;
43166
43258
  }
@@ -43191,12 +43283,22 @@ async function attachThroughStore(path, name, lead, artifacts, ctx) {
43191
43283
  return `error: the artifact store holds ${head.size} bytes for ${name}, not the ${size} measured${curlSaid} \u2014 nothing was posted`;
43192
43284
  }
43193
43285
  ctx.publish?.({ type: "artifact", direction: "out", key, name, size, contentType, callId });
43286
+ const finish2 = async (attached) => {
43287
+ if (!publishForPr) return attached;
43288
+ try {
43289
+ const url = await artifacts.store.publishPrImage(key);
43290
+ return `${attached}; public PR image (retained by the artifact lifecycle): ![${safeBasename(name)}](${url})`;
43291
+ } catch (err2) {
43292
+ return `error: ${attached}; PR publication failed: ${describe4(err2)} \u2014 no public PR image was returned`;
43293
+ }
43294
+ };
43194
43295
  if (!ctx.uploadTicket) {
43195
43296
  const link = artifacts.artifactUrl?.(key);
43196
43297
  const where = link ? ` \u2014 ${link}` : ` is on the run page as ${key}`;
43197
43298
  await artifacts.reply(`${lead}
43198
43299
  \u{1F4CE} ${name} (${size} bytes)${where}`);
43199
- return `attached ${name} (${size} bytes) to the run page as ${key}; this conversation's channel takes no file uploads, so the lead and the file's link were posted instead`;
43300
+ const linked = `attached ${name} (${size} bytes) to the run page as ${key}; this conversation's channel takes no file uploads, so the lead and the file's link were posted instead`;
43301
+ return publishForPr ? `error: ${linked}; PR publication requires a completed channel upload \u2014 no public PR image was published` : linked;
43200
43302
  }
43201
43303
  const kept = "the file is kept on the run page";
43202
43304
  let ticket;
@@ -43214,7 +43316,7 @@ async function attachThroughStore(path, name, lead, artifacts, ctx) {
43214
43316
  } catch (err2) {
43215
43317
  return `error: the channel refused to complete the upload of ${name}: ${describe4(err2)}; ${kept}`;
43216
43318
  }
43217
- return `attached ${name} (${size} bytes) to the conversation and the run page`;
43319
+ return finish2(`attached ${name} (${size} bytes) to the conversation and the run page`);
43218
43320
  }
43219
43321
  var MAX_ARTIFACT_BYTES, TRANSFER_FLOOR_BYTES_PER_MS, TRANSFER_SETUP_MS, STAT_TIMEOUT_MS, describe4, secs2, PUT_REPORT_FORMAT, attachFileTool;
43220
43322
  var init_attach = __esm({
@@ -43222,6 +43324,7 @@ var init_attach = __esm({
43222
43324
  "use strict";
43223
43325
  init_contentType();
43224
43326
  init_keys();
43327
+ init_prImages();
43225
43328
  init_runEvents();
43226
43329
  init_bashTimeout();
43227
43330
  init_binaryRead();
@@ -43236,11 +43339,15 @@ var init_attach = __esm({
43236
43339
  attachFileTool = {
43237
43340
  name: "attach_file",
43238
43341
  failsInText: true,
43239
- description: `Post a file from the workspace into the conversation so the person sees it inline \u2014 a screenshot (e.g. from \`playwright screenshot\`), a rendered PDF, a recording, a log. Use it whenever you produce an image worth showing: a link to a file is not a picture. Whole files only: up to ${MAX_ARTIFACT_BYTES} bytes (1 GiB) where the artifact store is configured, ${MAX_READ_BYTES} bytes otherwise \u2014 the result says which.`,
43342
+ description: `Post a file from the workspace into the conversation so the person sees it inline \u2014 a screenshot (e.g. from \`playwright screenshot\`), a rendered PDF, a recording, a log. Use it whenever you produce an image worth showing: a link to a file is not a picture. Whole files only: up to ${MAX_ARTIFACT_BYTES} bytes (1 GiB) where the artifact store is configured, ${MAX_READ_BYTES} bytes otherwise \u2014 the result says which. For an inspected, non-sensitive PNG screenshot, publishForPr: true also deliberately publishes a public PR image URL after thread delivery (10 MiB max, artifact retention applies). Omit it to keep the file private.`,
43240
43343
  inputSchema: {
43241
43344
  type: "object",
43242
43345
  properties: {
43243
43346
  path: { type: "string", description: "Relative path of the file in the workspace" },
43347
+ publishForPr: {
43348
+ type: "boolean",
43349
+ description: "Explicitly make this PNG public for embedding in a PR, after attaching it here. Default false; inspect/redact first. Requires a bound permitted repository and artifact store. The copy expires under the artifact lifecycle."
43350
+ },
43244
43351
  comment: {
43245
43352
  type: "string",
43246
43353
  description: "One line posted with the file saying what it shows (default: the file name)"
@@ -43253,7 +43360,16 @@ var init_attach = __esm({
43253
43360
  if (!path) return "error: path is required";
43254
43361
  const name = fileNameOf2(path);
43255
43362
  const lead = typeof input.comment === "string" && input.comment.trim() ? input.comment.trim() : name;
43256
- if (ctx.artifacts) return attachThroughStore(path, name, lead, ctx.artifacts, ctx);
43363
+ if (input.publishForPr !== void 0 && typeof input.publishForPr !== "boolean")
43364
+ return "error: publishForPr must be a boolean";
43365
+ const publishForPr = input.publishForPr === true;
43366
+ if (publishForPr) {
43367
+ if (!ctx.repo) return "error: PR publication requires a bound repository";
43368
+ if (!ctx.github?.canWrite(ctx.repo)) return "error: PR publication requires permission to use this repository";
43369
+ if (!ctx.artifacts) return "error: PR publication requires the artifact store; no public image was published";
43370
+ if (contentTypeFor(name) !== "image/png") return "error: PR publication supports only PNG screenshots";
43371
+ }
43372
+ if (ctx.artifacts) return attachThroughStore(path, name, lead, ctx.artifacts, ctx, publishForPr);
43257
43373
  if (!ctx.attach) {
43258
43374
  return "attach_file is not available here: this conversation's channel takes no file uploads \u2014 link to the file instead";
43259
43375
  }
@@ -49893,6 +50009,7 @@ function visiblePart(part) {
49893
50009
  }
49894
50010
  function fromPiMessage(message4, provider, tools) {
49895
50011
  if (message4.stopReason === "error" || message4.stopReason === "aborted") {
50012
+ if (message4.stopReason === "aborted") throw new DOMException("The model call was aborted", "AbortError");
49896
50013
  const raw = message4.errorMessage ?? "the model call failed";
49897
50014
  const failure2 = classifyProviderFailure({ error: raw, provider, model: message4.model });
49898
50015
  const schemaRejection = failure2.cause === "request-rejected" && failure2.status === 400 ? providerSchemaRejectionOf(raw, tools) : void 0;
@@ -50024,6 +50141,7 @@ var init_piAi = __esm({
50024
50141
  const message4 = await this.api$().stream(model, shaped.body, options).result();
50025
50142
  return fromPiMessage(message4, this.name, req.tools);
50026
50143
  } catch (err2) {
50144
+ if (err2 instanceof Error && err2.name === "AbortError") throw err2;
50027
50145
  const failure2 = providerFailureOf(err2);
50028
50146
  const schemaRejection = failure2.schemaRejection ?? (failure2.cause === "request-rejected" && failure2.status === 400 ? providerSchemaRejectionOf(err2, req.tools) : void 0);
50029
50147
  throw new ProviderFailure(failure2.cause, {
@@ -65091,14 +65209,13 @@ async function askModel(input, deps) {
65091
65209
  );
65092
65210
  return { ...seam.value, attempts: seam.attempts };
65093
65211
  } catch (err2) {
65094
- const timedOut = err2 instanceof Error && (err2.name === "TimeoutError" || err2.name === "AbortError");
65212
+ const timedOut = typedProviderFailureOf(err2) === void 0 && err2 instanceof Error && (err2.name === "TimeoutError" || err2.name === "AbortError");
65095
65213
  const attempts = attemptsOfThrow(err2);
65096
65214
  if (timedOut) {
65097
65215
  return {
65098
65216
  verdict: "silent",
65099
- reason: renderProviderFailure("transient", "ended"),
65217
+ reason: "The request timed out before it could start.",
65100
65218
  source: "timeout",
65101
- providerFailure: "transient",
65102
65219
  ...attempts ? { attempts } : {}
65103
65220
  };
65104
65221
  }
@@ -73870,28 +73987,49 @@ async function steerChild(body, deps) {
73870
73987
  const steered = await deps.steerChild?.(body.runId, text, instance) ?? false;
73871
73988
  return json3(200, { ok: true, outcome: steered ? "steered" : "not_live", at });
73872
73989
  }
73990
+ function publicationStepPrefix(row2) {
73991
+ const segment = row2.segments?.reduce((latest, entry) => Math.max(latest, entry.index), 1) ?? 1;
73992
+ if ((row2.segments?.filter((entry) => entry.index === segment).length ?? 0) > 1)
73993
+ throw new PublicationBindingRefusal("publication_facts_mismatch");
73994
+ const base = stepPrefixOf(row2.unit, segment > 1 ? { segment, renewalsSpent: 0, spendUsd: null } : void 0);
73995
+ const resumes = Object.entries(row2.wakes ?? {}).filter(
73996
+ ([, answer]) => answer.kind === "segment" && answer.index === segment && answer.leaseMs !== void 0
73997
+ );
73998
+ let prefix = base;
73999
+ for (let attempt = 1; attempt <= resumes.length; attempt += 1) {
74000
+ const idlePrefix = `${prefix}/idle/`;
74001
+ const answers = resumes.filter(
74002
+ ([key]) => key.startsWith(idlePrefix) && /^[1-9][0-9]*$/.test(key.slice(idlePrefix.length))
74003
+ );
74004
+ if (answers.length !== 1) throw new PublicationBindingRefusal("publication_facts_mismatch");
74005
+ prefix = `${base}/r${attempt}`;
74006
+ }
74007
+ return prefix;
74008
+ }
73873
74009
  async function advanceFindingsPublication(deps, instance, row2, facts2, runId3) {
73874
74010
  if (row2.publication === void 0 || facts2.headSha === row2.publication.expectedHeadSha) return row2;
73875
74011
  const child = await deps.runs.getRun(runId3, { include: "messages" });
73876
74012
  const owner = { instanceId: instance.id, unit: row2.unit };
73877
74013
  const latestReview = [...row2.rounds].reverse().find((round2) => round2.agent === "review");
73878
- const segment = row2.segments?.reduce((latest, entry) => Math.max(latest, entry.index), 1) ?? 1;
73879
- const prefix = row2.recovery !== void 0 ? `${row2.unit}/recovery` : stepPrefixOf(row2.unit, segment > 1 ? { segment, renewalsSpent: 0, spendUsd: null } : void 0);
74014
+ const prefix = row2.recovery !== void 0 ? `${row2.unit}/recovery` : publicationStepPrefix(row2);
73880
74015
  const findingsPrefix = `${instance.id}:${prefix}/${latestReview?.index}/findings`;
73881
74016
  if (row2.recovery === void 0) {
73882
74017
  const tags = child.ok ? child.value.events?.filter((event) => event.type === "coordinator_tag") ?? [] : [];
73883
74018
  const tag = tags.length === 1 ? tags[0] : void 0;
73884
- if (row2.ending !== void 0 || row2.idle !== void 0 || latestReview?.outcome !== "request_changes" || !child.ok || child.value.startedAt < latestReview.at || tag?.parentInstanceId !== instance.id || tag.unit !== row2.unit || tag.base !== instance.base || JSON.stringify(tag.publication) !== JSON.stringify(row2.publication))
74019
+ if (row2.ending !== void 0 || row2.idle !== void 0 || latestReview?.outcome !== "request_changes" || !child.ok || child.value.startedAt < latestReview.at || tag?.parentInstanceId !== instance.id || tag.unit !== row2.unit || tag.base !== instance.base || !samePublicationBinding(tag.publication, row2.publication))
73885
74020
  throw new PublicationBindingRefusal("publication_facts_mismatch");
73886
74021
  const listing = await deps.runs.listRuns({
73887
- status: "finished",
74022
+ status: "all",
73888
74023
  visibleTo: EVERY_RUN,
73889
74024
  threadKey: row2.threadKey ?? instance.threadKey,
73890
74025
  limit: RUN_LIST_MAX_LIMIT
73891
74026
  });
73892
- if (listing.storeUnavailable || listing.nextBefore !== void 0 || listing.runs.filter(
74027
+ const attempts = listing.runs.filter(
73893
74028
  (run2) => run2.parentInstanceId === instance.id && isStepAttempt(run2.idempotencyKey, findingsPrefix)
73894
- ).length !== 1)
74029
+ );
74030
+ if (listing.storeUnavailable || listing.nextBefore !== void 0 || attempts.filter((run2) => run2.id === runId3).length !== 1 || attempts.some(
74031
+ (run2) => run2.id !== runId3 && !(run2.finished && (run2.status === "failed" || run2.status === "interrupted") && run2.agent === "coding" && run2.userId === instance.userId && run2.repo?.toLowerCase() === instance.repo.toLowerCase() && run2.threadKey === (row2.threadKey ?? instance.threadKey) && run2.startedAt >= latestReview.at && run2.finishedAt !== void 0 && run2.finishedAt >= run2.startedAt && run2.finishedAt <= child.value.startedAt && (run2.pushed?.length ?? 0) === 0)
74032
+ ))
73895
74033
  throw new PublicationBindingRefusal("publication_facts_mismatch");
73896
74034
  }
73897
74035
  if (!child.ok || child.value.finished !== true || child.value.status !== "completed" || child.value.agent !== "coding" || child.value.parentInstanceId !== instance.id || child.value.userId !== instance.userId || child.value.repo?.toLowerCase() !== instance.repo.toLowerCase() || child.value.threadKey !== (row2.threadKey ?? instance.threadKey) || (row2.recovery !== void 0 ? child.value.idempotencyKey?.startsWith(`${instance.id}:${row2.unit}/recovery/`) !== true || !/\/findings(?:\/a[1-9][0-9]*)?$/.test(child.value.idempotencyKey ?? "") : !isStepAttempt(child.value.idempotencyKey, findingsPrefix)) || !fullHead(facts2.headSha) || child.value.headSha !== facts2.headSha || child.value.pushed?.some((push) => push.ref === row2.branch && push.sha === facts2.headSha) !== true || facts2.state !== "open" || facts2.sameRepoHead !== true || facts2.headBranchExists !== true || facts2.headRef !== row2.publication.headRef || facts2.baseRef !== row2.publication.baseRef || facts2.verifiedHead?.repo.toLowerCase() !== instance.repo.toLowerCase() || facts2.verifiedHead.ref !== row2.publication.publicationRef || facts2.verifiedHead.sha !== facts2.headSha || row2.publication.repo.toLowerCase() !== instance.repo.toLowerCase() || row2.publication.pr !== row2.pr?.number || row2.publication.baseRef !== instance.base || row2.publication.headRef !== row2.branch || row2.publication.publicationRef !== row2.branch || !samePublicationOwner(row2.publication.owner, owner))
@@ -74108,13 +74246,13 @@ async function recoverOriginalUnit(body, deps, caller) {
74108
74246
  const reviewThreadKey = row2.reviewThread?.threadKey ?? row2.threadKey ?? instance.threadKey;
74109
74247
  const unitThreadKey = row2.threadKey ?? instance.threadKey;
74110
74248
  const listing = await deps.runs.listRuns({
74111
- status: publication === void 0 ? "all" : "finished",
74249
+ status: "all",
74112
74250
  visibleTo: EVERY_RUN,
74113
74251
  threadKey: reviewThreadKey,
74114
74252
  limit: RUN_LIST_MAX_LIMIT
74115
74253
  });
74116
74254
  const unitListing = unitThreadKey === reviewThreadKey ? listing : await deps.runs.listRuns({
74117
- status: publication === void 0 ? "all" : "finished",
74255
+ status: "all",
74118
74256
  visibleTo: EVERY_RUN,
74119
74257
  threadKey: unitThreadKey,
74120
74258
  limit: RUN_LIST_MAX_LIMIT
@@ -74177,17 +74315,28 @@ async function recoverOriginalUnit(body, deps, caller) {
74177
74315
  const verified2 = facts2.verifiedHead;
74178
74316
  if (facts2.state !== "open" || facts2.sameRepoHead !== true || facts2.headBranchExists !== true || facts2.headRef !== row2.branch || facts2.baseRef !== base || !fullHead(facts2.headSha) || verified2 === void 0 || verified2.repo.toLowerCase() !== instance.repo.toLowerCase() || verified2.ref !== row2.branch || verified2.sha !== facts2.headSha)
74179
74317
  return json3(409, { ok: false, error: "recovery_facts_mismatch", at });
74180
- const findingsStep = `${segmentPrefix}/${boundary.index}/findings/pr-check`;
74181
- const findingsPrefix = `${instance.id}:${segmentPrefix}/${boundary.index}/findings`;
74318
+ const findingsStep = `${segmentPrefix}/${boundary.index}/findings`;
74319
+ const findingsPrefix = `${instance.id}:${findingsStep}`;
74182
74320
  const headMoved = facts2.headSha !== reviewedHead || expectedHead !== reviewedHead;
74183
- const completedFindings = unitListing.runs.filter(
74184
- (run2) => boundary.outcome === "request_changes" && row2.ending?.cause === "step_threw" && row2.ending.step === findingsStep && row2.ending.round === boundary.index && (expectedHead === reviewedHead || expectedHead === facts2.headSha) && run2.finished && run2.status === "completed" && run2.agent === "coding" && run2.parentInstanceId === instance.id && run2.userId === instance.userId && run2.repo?.toLowerCase() === instance.repo.toLowerCase() && run2.threadKey === unitThreadKey && isStepAttempt(run2.idempotencyKey, findingsPrefix) && run2.headSha === facts2.headSha && (!headMoved || run2.pushed?.some((push) => push.ref === row2.branch && push.sha === facts2.headSha) === true) && run2.startedAt >= (candidates2[0].finishedAt ?? candidates2[0].startedAt) && (run2.finishedAt === void 0 || run2.finishedAt <= row2.ending.at)
74185
- );
74186
- if (completedFindings.length > 1 || headMoved && completedFindings.length !== 1)
74187
- return json3(409, { ok: false, error: "recovery_head_moved", at });
74188
- if (completedFindings.length === 1) {
74189
- findingsRunId = completedFindings[0].id;
74190
- findingsKey = completedFindings[0].idempotencyKey;
74321
+ const failedStep = row2.ending.step;
74322
+ if (row2.ending.cause === "step_threw" && failedStep?.includes("/pr-check") === true) {
74323
+ const checkedStep = failedStep.slice(0, -"/pr-check".length);
74324
+ if (!failedStep.endsWith("/pr-check") || boundary.outcome !== "request_changes" || !isStepAttempt(checkedStep, findingsStep) || row2.ending.round !== boundary.index)
74325
+ return json3(409, { ok: false, error: "recovery_head_moved", at });
74326
+ const attempts = unitListing.runs.filter(
74327
+ (run2) => run2.parentInstanceId === instance.id && isStepAttempt(run2.idempotencyKey, findingsPrefix)
74328
+ );
74329
+ const checkedAttempts = attempts.filter((run2) => run2.idempotencyKey === `${instance.id}:${checkedStep}`);
74330
+ const completed = checkedAttempts.length === 1 ? checkedAttempts[0] : void 0;
74331
+ const reviewFinishedAt = review.finishedAt;
74332
+ if (reviewFinishedAt === void 0 || completed === void 0 || !completed.finished || completed.status !== "completed" || completed.agent !== "coding" || completed.userId !== instance.userId || completed.repo?.toLowerCase() !== instance.repo.toLowerCase() || completed.threadKey !== unitThreadKey || completed.pr !== void 0 && (completed.pr.number !== pr.number || completed.pr.head !== void 0 && completed.pr.head !== row2.branch) || completed.headSha !== facts2.headSha || expectedHead !== reviewedHead && expectedHead !== facts2.headSha || completed.pushed?.some((push) => push.ref !== row2.branch) === true || headMoved && completed.pushed?.some((push) => push.ref === row2.branch && push.sha === facts2.headSha) !== true || completed.startedAt < reviewFinishedAt || completed.finishedAt === void 0 || completed.finishedAt < completed.startedAt || completed.finishedAt > row2.ending.at || // Inspect every attempt, not just runs matching the current PR head:
74333
+ // any competing success or push remains ambiguous after a later push.
74334
+ attempts.some(
74335
+ (run2) => run2.id !== completed.id && !(run2.finished && (run2.status === "failed" || run2.status === "interrupted") && run2.agent === "coding" && run2.userId === instance.userId && run2.repo?.toLowerCase() === instance.repo.toLowerCase() && run2.threadKey === unitThreadKey && run2.startedAt >= reviewFinishedAt && run2.finishedAt !== void 0 && run2.finishedAt >= run2.startedAt && run2.finishedAt <= completed.startedAt && (run2.pushed?.length ?? 0) === 0)
74336
+ ))
74337
+ return json3(409, { ok: false, error: "recovery_head_moved", at });
74338
+ findingsRunId = completed.id;
74339
+ findingsKey = completed.idempotencyKey;
74191
74340
  expectedHead = facts2.headSha;
74192
74341
  kind = "review";
74193
74342
  round2 = boundary.index + 1;
@@ -74196,7 +74345,10 @@ async function recoverOriginalUnit(body, deps, caller) {
74196
74345
  lastPush: expectedHead,
74197
74346
  publication: { ...publication, expectedHeadSha: expectedHead }
74198
74347
  };
74199
- } else expectedHead = reviewedHead;
74348
+ } else {
74349
+ if (headMoved) return json3(409, { ok: false, error: "recovery_head_moved", at });
74350
+ expectedHead = reviewedHead;
74351
+ }
74200
74352
  const floor = minutesToMs(leaseMinimum(kind === "findings" ? "fix" : "review"));
74201
74353
  if (!Number.isFinite(remainingMs) || remainingMs < floor)
74202
74354
  return json3(409, { ok: false, error: "recovery_wall_clock_exhausted", at });
@@ -75835,7 +75987,7 @@ function createAdminCoordinatorHandler(deps) {
75835
75987
  })();
75836
75988
  };
75837
75989
  }
75838
- var COORDINATOR_ADMIN_PREFIX, MAX_SPAWN_PROMPT_CHARS, FINISHED_LOOKBACK_PAGES, MAX_ADMIN_BODY_BYTES, UNIT_ID, PRESETS_OF_KIND, invalid, json3, describe12, isGenerated2, EVERY_RUN, REVIEW_POSTED_CHECKS, REVIEW_POSTED_RECHECK_MS, githubSecondStart, CHILD_SUPERSESSION_REASONS, PublicationBindingRefusal, samePublicationOwner, fullHead, isStepAttempt, ROUND_OUTCOMES, ENDING_ICON, STEPS;
75990
+ var COORDINATOR_ADMIN_PREFIX, MAX_SPAWN_PROMPT_CHARS, FINISHED_LOOKBACK_PAGES, MAX_ADMIN_BODY_BYTES, UNIT_ID, PRESETS_OF_KIND, invalid, json3, describe12, isGenerated2, EVERY_RUN, REVIEW_POSTED_CHECKS, REVIEW_POSTED_RECHECK_MS, githubSecondStart, CHILD_SUPERSESSION_REASONS, PublicationBindingRefusal, samePublicationOwner, samePublicationBinding, fullHead, isStepAttempt, ROUND_OUTCOMES, ENDING_ICON, STEPS;
75839
75991
  var init_adminCoordinator = __esm({
75840
75992
  "../../src/channels/adminCoordinator.ts"() {
75841
75993
  "use strict";
@@ -75899,6 +76051,7 @@ var init_adminCoordinator = __esm({
75899
76051
  reason;
75900
76052
  };
75901
76053
  samePublicationOwner = (left, right) => left?.instanceId === right.instanceId && left.unit === right.unit;
76054
+ samePublicationBinding = (left, right) => left !== void 0 && left.repo === right.repo && left.pr === right.pr && left.headRef === right.headRef && left.baseRef === right.baseRef && left.expectedHeadSha === right.expectedHeadSha && left.publicationRef === right.publicationRef && samePublicationOwner(left.owner, right.owner);
75902
76055
  fullHead = (value) => typeof value === "string" && /^[0-9a-f]{40}$/i.test(value);
75903
76056
  isStepAttempt = (key, step) => key === step || key?.startsWith(`${step}/a`) === true && /^[1-9][0-9]*$/.test(key.slice(step.length + 2));
75904
76057
  ROUND_OUTCOMES = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coreplane/switchboard",
3
- "version": "1.262.4",
3
+ "version": "1.263.0",
4
4
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://openswitchboard.dev",